import { NextResponse, type NextRequest } from 'next/server'; import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { getSql } from '@rareindex/database'; import { ensureOriginal, ensureVariant, findOriginal, imageKey, nearestWidth, negativeFor, variantPath, type OriginalInfo } from '@/lib/images-core'; import { verifyImageSignature } from '@/lib/images'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; const IMMUTABLE = 'public, max-age=31536000, immutable'; const NEGATIVE = 'public, max-age=3600, stale-while-revalidate=600'; /** * GET /img/.?w=&u=&s= * Serves a cached, resized copy of a third-party product image. The original URL must be * HMAC-signed by the server (any page that renders it) or already known in the `images` table. */ export async function GET(req: NextRequest, ctx: { params: Promise<{ key: string }> }) { const { key: rawKey } = await ctx.params; const m = rawKey.match(/^([a-f0-9]{40})(?:\.(webp|avif))?$/); if (!m) return new NextResponse('Not found', { status: 404, headers: { 'cache-control': NEGATIVE } }); const key = m[1]!; const fmt = (m[2] as 'webp' | 'avif' | undefined) ?? 'webp'; const width = nearestWidth(req.nextUrl.searchParams.get('w')); // Fast path: variant already on disk. try { const vpath = variantPath(key, width, fmt); const s = await stat(vpath); return fileResponse(vpath, s.size, `image/${fmt}`, req); } catch { /* build below */ } // Resolve the source URL: signed param first, then DB lookup by cache key / sha1(url). let url: string | null = null; const u = req.nextUrl.searchParams.get('u'); const s = req.nextUrl.searchParams.get('s'); if (u && s) { try { const decoded = Buffer.from(u, 'base64url').toString('utf8'); if (imageKey(decoded) === key && verifyImageSignature(decoded, s)) url = decoded; } catch { url = null; } } if (!url) { try { const sql = getSql(); const rows = (await sql`select url from images where cache_key = ${key} limit 1`) as Array<{ url: string }>; url = rows[0]?.url ?? null; } catch { url = null; } } let info: OriginalInfo | null = await findOriginal(key); if (!info) { if (!url) return new NextResponse('Unknown image', { status: 404, headers: { 'cache-control': NEGATIVE } }); if (negativeFor(url)) return new NextResponse('Image unavailable', { status: 404, headers: { 'cache-control': NEGATIVE, 'x-ri-image': 'negative-cache' } }); const out = await ensureOriginal(url); if (!out.ok) { void recordFailure(key, url, out.status, out.reason); return new NextResponse('Image unavailable', { status: 404, headers: { 'cache-control': NEGATIVE, 'x-ri-image': out.status } }); } info = out.info; } try { const vpath = await ensureVariant(info, width, fmt); const st = await stat(vpath); return fileResponse(vpath, st.size, `image/${fmt}`, req); } catch (err) { console.error('[img] variant failed', key, err instanceof Error ? err.message : err); return new NextResponse('Image processing failed', { status: 500, headers: { 'cache-control': 'no-store' } }); } } function fileResponse(filePath: string, size: number, contentType: string, req: NextRequest): NextResponse { const etag = `"${size}-${filePath.slice(-24).replace(/[^a-z0-9]/gi, '')}"`; if (req.headers.get('if-none-match') === etag) return new NextResponse(null, { status: 304, headers: { etag, 'cache-control': IMMUTABLE } }); const stream = Readable.toWeb(createReadStream(filePath)) as unknown as ReadableStream; return new NextResponse(stream, { status: 200, headers: { 'content-type': contentType, 'content-length': String(size), 'cache-control': IMMUTABLE, etag, 'x-content-type-options': 'nosniff', 'accept-ch': 'DPR, Width' }, }); } async function recordFailure(key: string, url: string, status: string, reason: string): Promise { try { const sql = getSql(); await sql`update images set status = ${status}, error = ${reason.slice(0, 200)}, checked_at = now(), cache_key = ${key} where url = ${url}`; } catch { /* best effort */ } }